Skip to content

perf(mode): pin sliced mode-solver plane to single-device sharding - #401

Open
pic-asso wants to merge 2 commits into
ymahlau:mainfrom
pic-asso:feat/pin-mode-solver-plane-single-device
Open

perf(mode): pin sliced mode-solver plane to single-device sharding#401
pic-asso wants to merge 2 commits into
ymahlau:mainfrom
pic-asso:feat/pin-mode-solver-plane-single-device

Conversation

@pic-asso

@pic-asso pic-asso commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

What

On multi-GPU runs, the field/material arrays (inv_permittivities / inv_permeabilities)
are x-sharded across the device mesh. ModePlaneSource and ModeOverlapDetector slice a
thin transverse plane out of these arrays and feed it to the Tidy3D mode-solver, which runs
as a pure_callback pinned to a single device.

Without an explicit sharding constraint on the sliced plane, XLA can conservatively
all-gather the full x-sharded parent array (~100M cells) to satisfy the callback's
single-device requirement, rather than gathering just the thin sliced plane (a few hundred
KB) it actually needs.

How

Adds pin_to_single_device in fdtdx/core/jax/sharding.py: applies a with_sharding_constraint
that replicates the already-sliced plane across the full device mesh (not a plain
SingleDeviceSharding, which would raise "incompatible devices for jitted computation" against
the surrounding x-sharded computation). This tells XLA to gather only the small plane.

No-op on single-device runs (len(jax.devices()) == 1), so the single-GPU path is byte-identical
to before.

Applied at the two mode-solver call sites: ModePlaneSource.place_on_grid and
ModeOverlapDetector.update.

Tests

tests/unit/core/jax/test_sharding.py (TestPinToSingleDevice, 4 new tests):

  • single-device no-op returns the identical array object;
  • multi-device: shape/dtype/values preserved (run in an isolated subprocess with 2 virtual
    CPU devices via XLA_FLAGS=--xla_force_host_platform_device_count=2, since the replicated
    path needs genuinely distinct physical devices — mocking jax.devices() with a duplicated
    device raises ValueError: ... must be from distinct devices on the eager
    with_sharding_constraint call);
  • multi-device: result is a fully replicated NamedSharding (empty PartitionSpec);
  • regression test for the real usage pattern — slicing a plane out of an x-sharded array
    inside jax.jit and pinning it must not raise the "incompatible devices" error.

All new tests verified to fail if the multi-device branch is stubbed out (sanity-checked
locally by temporarily forcing the no-op path unconditionally).

Summary by CodeRabbit

  • New Features

    • Added device pinning for sliced simulation data to ensure host callbacks receive only the intended slice in multi-device runs.
  • Bug Fixes

    • Prevented unnecessary transfers/gathering of larger arrays when running mode-related computations.
    • Improved reliability of mode-solver callback outputs by aligning device placement to avoid sharding conversion errors.
  • Tests

    • Added multi-device regression coverage (including callback and JIT scenarios) to validate correct replicated behavior and pinning semantics.

On multi-GPU runs the field/material arrays are x-sharded. Slicing a thin
transverse plane out of one of these arrays to feed the Tidy3D mode-solver
pure_callback (which runs on a single device) could otherwise make XLA
all-gather the full ~100M-cell parent array to satisfy the callback's
single-device requirement. pin_to_single_device replicates just the already
-sliced plane across the mesh instead, so only the thin band is gathered.
No-op on single-device runs.
@coderabbitai

coderabbitai Bot commented Jul 9, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: ab17ccf6-0fd8-4b58-a3ca-292ee072d31e

📥 Commits

Reviewing files that changed from the base of the PR and between f7fd65f and 59c2ac5.

📒 Files selected for processing (5)
  • src/fdtdx/core/jax/sharding.py
  • src/fdtdx/core/physics/modes.py
  • src/fdtdx/objects/detectors/mode.py
  • src/fdtdx/objects/sources/mode.py
  • tests/unit/core/jax/test_sharding.py
🚧 Files skipped from review as they are similar to previous changes (3)
  • src/fdtdx/objects/sources/mode.py
  • src/fdtdx/objects/detectors/mode.py
  • tests/unit/core/jax/test_sharding.py

📝 Walkthrough

Walkthrough

Adds replicated sharding support for mode-related arrays. Sliced material tensors and mode pure_callback outputs are pinned before downstream processing, with subprocess tests covering single- and multi-device behavior.

Changes

Mode callback sharding

Layer / File(s) Summary
Replicated sharding helper
src/fdtdx/core/jax/sharding.py
Adds replicated mesh construction and pin_to_single_device, with a single-device no-op path and multi-device replicated placement.
Mode input integration
src/fdtdx/objects/detectors/mode.py, src/fdtdx/objects/sources/mode.py
Pins sliced permittivity and permeability tensors in detector and source apply() paths.
Mode output integration
src/fdtdx/core/physics/modes.py
Pins electric fields, magnetic fields, and effective-index outputs from jax.pure_callback.
Multi-device validation
tests/unit/core/jax/test_sharding.py
Adds subprocess-based tests for replicated placement, sliced JIT inputs, pure callback outputs, and array preservation.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Suggested reviewers: ymahlau, teevee112, l-berg

Poem

A rabbit pins each wandering array,
Across the mesh, replicated fair.
Callback fields now settle right,
While sliced planes hop light.
🐇✨ No sharding thorns in sight!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is concise and accurately captures the main change: pinning sliced mode-solver planes to single-device sharding.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@codecov

codecov Bot commented Jul 9, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 78.94737% with 4 lines in your changes missing coverage. Please review.
✅ Project coverage is 90.73%. Comparing base (b443f77) to head (59c2ac5).
⚠️ Report is 19 commits behind head on main.

Files with missing lines Patch % Lines
src/fdtdx/core/jax/sharding.py 66.66% 2 Missing and 1 partial ⚠️
src/fdtdx/objects/detectors/mode.py 66.66% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #401      +/-   ##
==========================================
- Coverage   90.77%   90.73%   -0.05%     
==========================================
  Files          91       92       +1     
  Lines       10588    11459     +871     
  Branches     1582     1730     +148     
==========================================
+ Hits         9611    10397     +786     
- Misses        686      730      +44     
- Partials      291      332      +41     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Comment thread src/fdtdx/objects/sources/mode.py Outdated
inv_permittivity_slice = inv_permittivities[:, *self.grid_slice]
# Pin the sliced PLANE to a single-device replicated layout (no-op on 1 GPU): the slice
# feeds the host mode-solver pure_callback (device 0), so this gathers only the thin band
# rather than letting XLA all-gather the full x-sharded parent array.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment is duplicated, a bit overdocumented

@ymahlau

ymahlau commented Jul 10, 2026

Copy link
Copy Markdown
Owner

Hi @pic-asso, thanks for the PR! I currently do not have easy access to a multi-gpu setup to verify this behavior. Could you provide some numbers / screenshot /logs or something similar to show that this PR does achieve what it claims?

Don't get me wrong, the reasoning and code all makes sense, its just that I currently have no way of verifying.

pin_to_single_device only covered the mode solver's sliced INPUT plane;
the pure_callback's own OUTPUTS (mode_E_raw, mode_H_raw, eff_idx) carry
a maximal/single-device GSPMDSharding that is incompatible with the
surrounding x-sharded computation and was left unpinned, so a 2-GPU run
still crashed inside compute_mode.

jax.lax.with_sharding_constraint cannot convert that GSPMDSharding
either (raises "Cannot convert GSPMDSharding {maximal device=0} into
SdyArray" via Shardy), so pin_to_single_device now uses jax.device_put,
which handles both the already-NamedSharding sliced-input case and the
maximal-sharded callback-output case.

Verified bit-exact (1-GPU vs 2-GPU overlap identical to full float32
precision) on a non-PDK benchmark, with no speedup from 2 GPUs (fixed
per-step sync + serial host callback dominate) but a real per-device
memory reduction, matching the point of ymahlau#401.

Also: shorten the duplicated pinning comment in sources/mode.py and
detectors/mode.py per review feedback, and extract
_replicated_mesh_sharding() as a separately-testable pure function to
close the patch-coverage gap on sharding.py (mocked-device-list tests
cover the mesh/sharding construction directly; the final device_put
placement still requires genuinely distinct physical devices).
@pic-asso

pic-asso commented Jul 15, 2026

Copy link
Copy Markdown
Contributor Author

Pushed a follow-up commit (59c2ac5) addressing the review feedback plus a real gap I found while benchmarking on real multi-GPU hardware.

What's new in this commit

  1. The original fix only pinned the sliced input to the mode-solver's pure_callback, not the callback's own outputs. mode_E_raw, mode_H_raw, and eff_idx come back from the host callback carrying a maximal/single-device GSPMDSharding, which is incompatible with the surrounding x-sharded computation. On 2 GPUs this still crashed inside compute_mode, just one step later than before. Now pinned via the same pin_to_single_device.
  2. jax.lax.with_sharding_constraint can't handle that specific sharding representation — it raises Cannot convert GSPMDSharding {maximal device=0} into SdyArray internally via XLA's Shardy partitioner. Switched pin_to_single_device's implementation to jax.device_put, which performs the placement directly and accepts both the already-NamedSharding sliced-input case and the maximal-sharded callback-output case.
  3. Shortened the duplicated pinning comment in sources/mode.py / detectors/mode.py per the "overdocumented" nit.
  4. Extracted _replicated_mesh_sharding() as a separately-testable pure function to close the patch-coverage gap — the mesh/sharding construction can now be unit-tested directly with a mocked device list, without needing the subprocess-based multi-device harness. Local coverage on sharding.py is now 96% (branch), with the remaining lines being the actual device_put placement call, which genuinely requires distinct physical devices to exercise.

Benchmark (real 2×H100 hardware, non-PDK)

To validate this against real multi-GPU hardware without touching any confidential PDK geometry, I wrote a generic standalone benchmark: a straight strip waveguide (n_core=2.0 / n_clad=1.44, made-up numbers, no tie to any real platform), ~121M cells, with a ModePlaneSource feeding a downstream ModeOverlapDetector — same code path as compute_mode. The script monkey-patches pin_to_single_device to a no-op to simulate pre-#401 behavior on the same checkout, so all three phases below ran from the exact same commit.

Ran 3 phases in one SLURM allocation (1×H100 reference, 2×H100 without the callback-output pinning, 2×H100 with it):

Phase Overlap result Peak GPU memory Wall time
1-GPU reference 720.21387-111.2457j 18.3 GB (GPU0) ~168s
2-GPU, output pinning off crashes at the callback-output sharding (the gap this commit fixes)
2-GPU, output pinning on 720.21387-111.2457j 17.3 GB (GPU0) + 13.2 GB (GPU1) ~171s

Bit-exact correctness (identical overlap to full float32 precision) between 1-GPU and the fully-patched 2-GPU run — that, plus no longer crashing, is the actual point of this fix.

I'd flag the memory/timing columns above as inconclusive on their own: GPU0 only drops 18.3→17.3GB (~5%), the 2-GPU total (30.5GB) is actually higher than the 1-GPU total (18.3GB), and the split is uneven (17.3 vs 13.2) — at this size the fixed overhead (mesh setup, program binary, small non-sharded state) is a large enough fraction of the total that it can obscure a real halving one way or the other. Wall-clock is flat-to-slightly-worse too (~168s vs ~171s).

To get a cleaner read, I re-ran the same scene ~1.6× larger (NX 2500→4000, ~194M cells instead of ~121M):

Phase Peak GPU memory Wall time
1-GPU reference 19.6 GB (GPU0) ~460s
2-GPU, patched 17.5 GB (GPU0) + 17.5 GB (GPU1) ~467s

Here the 2-GPU split is genuinely even (17.51 vs 17.51 GB, not 17.3 vs 13.2) and both devices sit below the 1-GPU figure — a much more convincing sharded-not-replicated signature, even though the wall-clock still doesn't improve (same story: fixed per-step sync + the serial callback dominate). I don't want to overclaim a big memory win from either benchmark, but the larger one is a materially cleaner demonstration than the original table, so pairing them here for completeness.

Edit: removed a closing claim here about PhasorDetector needing separate handling — a follow-up reproduction showed that was wrong (a scene with a PhasorDetector runs cleanly under this fix; the crash I'd seen earlier was this same callback-output issue, not PhasorDetector). No action needed there.

Final wrap-up: while chasing the above I also found that gradient-mode multi-GPU (both method="reversible" and "checkpointed") doesn't shard its backward-pass state at all — replicates the full buffer on every device instead. That's unrelated to this PR's scope (mode-solver pure_callback sharding) and doesn't touch PhasorDetector either, so I filed it separately as #417 rather than folding it in here. From my side this PR is done — the fix, tests, and benchmarks above are the full extent of what I have.



def _replicated_mesh_sharding(compute_devices: list[jax.Device]) -> jax.sharding.NamedSharding:
"""Build a REPLICATED NamedSharding over ``compute_devices`` -- NOT SingleDeviceSharding, whose

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please remove all of the AI chain-of-thoughts comments. We want to have actual docstrings, not a development story


# inv_permittivities shape: (3, Nx, Ny, Nz) - slice with component dimension
inv_permittivity_slice = inv_permittivities[:, *self.grid_slice]
# Pin the sliced plane to a single device (see pin_to_single_device docstring for why).

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

references to other comments are generally not good style

assert isinstance(result.sharding, jax.sharding.NamedSharding)


# ---- _replicated_mesh_sharding ----

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remove excessive comments and unnecessary spacing



class TestReplicatedMeshSharding:
"""Tests for the _replicated_mesh_sharding helper, split out of pin_to_single_device so this

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment is also convoluted

_run_in_subprocess_with_devices(
2,
"""
import jax, jax.numpy as jnp

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there any way to run these unit tests without having python code as strings? This kind of code is hard to maintain, maybe a better variant would be defining a helper function directly as python code and executing that

@ymahlau

ymahlau commented Jul 16, 2026

Copy link
Copy Markdown
Owner

Hi @pic-asso, the pre-commit checks are currently failing an the PR needs some re-work to remove the AI bloat. From a functional point of view this looks good to me

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants